feat: add parallel chunk processing for large documents in transformations - #529
feat: add parallel chunk processing for large documents in transformations#529kevincolten wants to merge 14 commits into
Conversation
There was a problem hiding this comment.
2 issues found across 6 files
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="open_notebook/utils/token_utils.py">
<violation number="1" location="open_notebook/utils/token_utils.py:153">
P2: `is_context_limit_error` matches generic substrings like "limit" and "exceeded", so common rate-limit errors (e.g., "Rate limit exceeded") will be treated as context-length errors. In transformation, that triggers parallel chunk retries, likely worsening rate limiting and causing retry spikes.</violation>
<violation number="2" location="open_notebook/utils/token_utils.py:268">
P2: Sentence-level splitting can still create chunks that exceed max_tokens when a single sentence is longer than the limit, violating the function’s contract and potentially re-triggering context-limit errors.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="open_notebook/utils/token_utils.py">
<violation number="1" location="open_notebook/utils/token_utils.py:310">
P2: `current_chunk` is set to a list of words, but remaining chunks are joined with `"\n\n"`, so an oversized sentence fragment at the end will be emitted with double newlines between every word instead of spaces.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
… synthesis When a transformation's full content exceeds the model's context window, split it into token-sized chunks, process them in parallel, and synthesize the partial results back into one output. The synthesis is reduced hierarchically — chunk results are batched to fit a token budget and combined in rounds — so merging many (or large) results never overflows the context window either. - token_utils: add context-limit error parsing (OpenAI/Anthropic/Google), is_context_limit_error, token-aware text chunking, and output-buffer helpers. - transformation: try_full_content -> fan_out_chunks -> process_chunk -> synthesize_results (with budgeted reduce). Preserves the single-call fast path and upstream's classify_error behavior. - tests: chunking helpers, fan-out routing, and a regression test asserting synthesis batches instead of overflowing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3c76e16 to
19448a1
Compare
lfnovo
left a comment
There was a problem hiding this comment.
Thanks @kevincolten — this is a genuinely nice piece of work: the optimistic-full → chunk-on-context-error → parallel Send → hierarchical synthesis flow is well structured, it keeps Prompter intact, doesn't disturb the token_count fix, and it's well tested (the 145K-token real run is a great touch). Two blocking items before it can merge, plus a few smaller notes.
Blocking
1. Output cap silently drops from 8192 → 4096 on the normal (non-chunked) path.
try_full_content sets output_buffer = DEFAULT_OUTPUT_TOKENS (4096) and calls provision_langchain_model(..., max_tokens=output_buffer). main currently uses max_tokens=8192. Since try_full runs for every transformation (not just large docs), any transformation whose output exceeds 4096 tokens would now be truncated — a silent regression on the common path. Please preserve the current 8192 default for the full-content attempt (or make the output budget configurable and default it to 8192).
2. Module-level asyncio.Semaphore bound at import.
_chunk_semaphore = asyncio.Semaphore(_CHUNK_CONCURRENCY_LIMIT) # module scopeThere are no other module-level asyncio primitives in the codebase, and this one is a footgun: an asyncio.Semaphore binds to the first event loop it's awaited from. This graph is imported by the worker (run_transformation_command) and can also be exercised from the API's loop; awaiting the same module-level semaphore from two loops raises RuntimeError: bound to a different event loop. Please create it inside the function (or lazily per-invocation) rather than at import.
Non-blocking (worth addressing)
-
Context-limit detection parses provider error strings (
is_context_limit_error/get_context_limit_from_error). That's inherently provider-format-dependent and will silently fall back toDEFAULT_CONTEXT_LIMIT(8192) when a wording doesn't match, which can mis-size chunks. A short comment on the supported formats + how the fallback behaves would help future maintenance. -
Chunk meta-prefix leaks into content. Each chunk is sent as
"[Processing section X of N from a larger document]\n\n{chunk}". For summarization that's fine, but for extraction-style transformations that instruction text can bleed into or skew the output. Consider putting the "section X of N" hint in the system prompt instead of the user content. -
_CHUNK_CONCURRENCY_LIMIT = 3is a second concurrency layer on top of the worker's own limit (OPEN_NOTEBOOK_WORKER_MAX_TASKS, see #893). Worth a comment noting the interaction, or deriving it from the same config.
Process note
There's no linked issue — features go through an approved issue first (CONTRIBUTING). The feature itself is well-aligned, so this is easy to formalize; I'll get an issue opened to track it. Once (1) and (2) are addressed I'm happy to re-review. (Heads up: maintainerCanModify is off on this PR, so these need to come from your side.)
|
Opened #990 to track this feature (with the review notes captured as design/acceptance criteria) — this PR is the implementation for it. Once the two blocking items above are addressed, happy to re-review. 🙏 |
- Restore the 8192 output cap on the full-content path: bump DEFAULT_OUTPUT_TOKENS from 4096 to 8192 so the optimistic attempt matches the pre-chunking max_tokens and never truncates outputs on the common path. - Create chunk semaphores lazily per event loop instead of at module import: an asyncio.Semaphore binds to the loop it is first awaited from, and the graph runs from both the worker's and the API's loops. - Move the "section X of N" hint from the user content into the system prompt so it can't bleed into extraction-style outputs. - Document the supported provider error formats and the DEFAULT_CONTEXT_LIMIT fallback behavior in token_utils. - Note the interaction between _CHUNK_CONCURRENCY_LIMIT and the worker's task limit (OPEN_NOTEBOOK_WORKER_MAX_TASKS). - Add tests for the 8192 cap, verbatim chunk content, and per-loop semaphores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
Thanks for the thorough review @lfnovo! All items addressed in 69e5156: Blocking:
Non-blocking:
Also linked the PR to #990. All 204 tests pass, ruff clean. Ready for re-review 🙏 |
…chunking # Conflicts: # open_notebook/graphs/transformation.py
The chunking work split run_transformation into try_full_content -> process_chunk -> synthesize_results. try_full_content owns the non-chunking path and its add_insight() call, so it is where upstream's propagation contract now lives.
- parse_context_limit_error: widen to Optional[Tuple[Optional[int], int]]. The docstring already documented that tokens_sent may be None when only the limit is parseable, and its sole caller (get_context_limit_from_error) already returns that wider type. - test_graphs: narrow await_args before attribute access, and type the process_chunk state as ChunkState.
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
lfnovo
left a comment
There was a problem hiding this comment.
Thanks for keeping this alive and for addressing both blockers from the last round (8192 default restored, per-event-loop semaphore). Issue #990 is ready and this is the implementation, so let's get it over the line. Two things remain, both small, and one is newly important:
- Wrap the chunk-path LLM calls in
classify_error().process_chunkand_synthesize_oncelet raw provider exceptions escape. #1275 (merged this week) addedContextLengthExceededErrorto the retry blocklists incommands/source_commands.py, so an unclassified exception from a chunk node will now be retried by surreal-commands with the full exponential budget, which is exactly the behavior #1275 removed. Route them through the sameclassify_error→raise error_class(...) from epattern the single-shot path uses inopen_notebook/graphs/transformation.py. - Reuse
open_notebook/utils/error_classifier.pyinstead of a second keyword list.token_utils.is_context_limit_errorduplicates the context-limit detection main already owns; the two will drift. Build one on the other.
Two non-blocking notes for your judgment: calculate_output_buffer(8192) yields ~819 output tokens per chunk when the error wording doesn't parse, which seems likely to truncate chunk results; and the synthesis prompt ("merge, remove redundancy") is right for summaries but lossy for extraction-style transformations, silently. A sentence in the docs about that trade-off would be enough.
Please rebase on main and add a CHANGELOG line under Unreleased → Added.
…detection on error_classifier Addresses the 2026-09-05 review on lfnovo#529 (issue lfnovo#990). - process_chunk and _synthesize_once now route provider exceptions through classify_error() via a shared _invoke_llm helper, so a context-length rejection from a chunk surfaces as ContextLengthExceededError and the worker's stop_on list (lfnovo#1275) stops retrying it; other failures get the same sanitized, typed errors as the single-shot path. - token_utils.is_context_limit_error is a thin wrapper over classify_error; the duplicate keyword lists are gone. error_classifier's context-length rule gains the Anthropic/Google/"context window" wordings that only lived in token_utils. HTTP 413 / "request too large" deliberately no longer triggers chunking (payload limit, not a token window). - classify_error matches numeric status codes ("401", "429", "500", ...) as standalone numbers. Substring matching read "429" out of token counts like "142900 tokens > 200000 maximum", turning a context-length error into a RateLimitError that was retried instead of chunked (same class of bug as lfnovo#1303). - Chunk output budget floors at 2048 tokens (capped at a quarter of the window) instead of 10% of an unparsed 8192 default (819 tokens). - Google's current "exceeds the maximum number of tokens allowed (N)" wording is parsed for the limit. - Docs: Large Documents section in the transformations guide covering the synthesis trade-off for extraction-style prompts. CHANGELOG entry. - Tests: classification/parsing tables, chunk and synthesis error paths, try_full_content chunk fallback; renamed the node-level propagation test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011V8PghiyLvfhK5meqkU4QQ
|
Addressed in 8b0084a. The branch is current with 1. Chunk-path errors are classified
2. One keyword list
Two side effects to flag:
Non-blocking notes
CHANGELOG line added under Unreleased → Added. New |
There was a problem hiding this comment.
1 issue found across 9 files (changes from recent commits).
Confidence score: 4/5
open_notebook/utils/token_utils.pymay stop recognizing some provider context-limit errors when the message says only “exceeds the maximum,” causing the expected context-length handling or fallback to be skipped—restore the previously supported wording inis_context_limit_error().
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="open_notebook/utils/token_utils.py">
<violation number="1" location="open_notebook/utils/token_utils.py:186">
P2: When a provider reports a context rejection as `exceeds the maximum` without `context` or `input token count`, `is_context_limit_error()` now returns false. Add the previously supported context wording to the shared classifier rule so these errors still trigger chunking.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| generic external error and return False here, so callers treat them as a | ||
| regular failure rather than chunking on them.""" | ||
| error_class, _ = classify_error(error) | ||
| return issubclass(error_class, ContextLengthExceededError) |
There was a problem hiding this comment.
P2: When a provider reports a context rejection as exceeds the maximum without context or input token count, is_context_limit_error() now returns false. Add the previously supported context wording to the shared classifier rule so these errors still trigger chunking.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At open_notebook/utils/token_utils.py, line 186:
<comment>When a provider reports a context rejection as `exceeds the maximum` without `context` or `input token count`, `is_context_limit_error()` now returns false. Add the previously supported context wording to the shared classifier rule so these errors still trigger chunking.</comment>
<file context>
@@ -160,58 +172,18 @@ def parse_context_limit_error(error: Exception) -> Optional[Tuple[Optional[int],
+ generic external error and return False here, so callers treat them as a
+ regular failure rather than chunking on them."""
+ error_class, _ = classify_error(error)
+ return issubclass(error_class, ContextLengthExceededError)
</file context>
There was a problem hiding this comment.
Partly valid. The bare phrase was dropped on purpose: "exceeds the maximum" also describes upload sizes and request counts, and the old list only avoided that by checking a separate non-context blocklist first. 178b33c re-adds it as a token-qualified pattern ("token(s)" within 40 chars of "exceeds the maximum", either order), so "9000 tokens exceeds the maximum of 8192" chunks while "file exceeds the maximum upload size" does not. Tests cover both. Also added Bedrock's "Input is too long for requested model".
…ength rule, re-add token-qualified "exceeds the maximum" Follow-up to cubic's review of 8b0084a: - "too many tokens per minute" style throttles matched the new "too many tokens" context keyword. The rate-limit rule (which runs first) now also matches "tokens per minute", "tokens per min" and "(tpm)", so token-rate limits stay retryable and never trigger chunking. - The bare "exceeds the maximum" wording from the old token_utils list is back as a compiled pattern that requires "token(s)" within 40 chars, so "9000 tokens exceeds the maximum of 8192" chunks while "file exceeds the maximum upload size" does not. Rules may now hold regex patterns next to substrings; _keyword_matches searches them. - Bedrock's "Input is too long for requested model" is recognised. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011V8PghiyLvfhK5meqkU4QQ
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Confidence score: 5/5
- In
tests/test_context_length_no_retry.py,test_status_codes_still_match_as_standalone_numbersnow also contains token-rate throttle cases, which mixes unrelated concerns and could make failures harder to diagnose; move those cases into a dedicated test.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="tests/test_context_length_no_retry.py">
<violation number="1" location="tests/test_context_length_no_retry.py:113">
P3: The two new token-rate throttle cases were appended to `test_status_codes_still_match_as_standalone_numbers`, a test whose stated concern (and name) is that status codes like 429 match only as standalone numbers. These cases contain no status code; they pin token-throttle wording to RateLimitError instead. They test a different concern and read better as their own parametrized test (or a rename), so a classification regression in either path isn't attributed to the right guard.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| # Token-rate throttles mention tokens but are transient, not a | ||
| # context window: they must stay retryable and must not chunk. | ||
| ("Too many tokens per minute for this model, slow down.", RateLimitError), | ||
| ("Request too large for model on tokens per min (TPM): Limit 6000", RateLimitError), |
There was a problem hiding this comment.
P3: The two new token-rate throttle cases were appended to test_status_codes_still_match_as_standalone_numbers, a test whose stated concern (and name) is that status codes like 429 match only as standalone numbers. These cases contain no status code; they pin token-throttle wording to RateLimitError instead. They test a different concern and read better as their own parametrized test (or a rename), so a classification regression in either path isn't attributed to the right guard.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_context_length_no_retry.py, line 113:
<comment>The two new token-rate throttle cases were appended to `test_status_codes_still_match_as_standalone_numbers`, a test whose stated concern (and name) is that status codes like 429 match only as standalone numbers. These cases contain no status code; they pin token-throttle wording to RateLimitError instead. They test a different concern and read better as their own parametrized test (or a rename), so a classification regression in either path isn't attributed to the right guard.</comment>
<file context>
@@ -102,6 +107,10 @@ def test_provider_wordings_are_context_length(self, message):
+ # Token-rate throttles mention tokens but are transient, not a
+ # context window: they must stay retryable and must not chunk.
+ ("Too many tokens per minute for this model, slow down.", RateLimitError),
+ ("Request too large for model on tokens per min (TPM): Limit 6000", RateLimitError),
("Error code: 401 - invalid api key", AuthenticationError),
("Error code: 503 - service unavailable", ExternalServiceError),
</file context>
Description
Enable smaller context models to process large documents by automatically chunking content that exceeds context limits and processing chunks in parallel using LangGraph's Send API.
How it works:
try_full_content- Attempts to process entire document optimisticallyfan_out_chunks- Creates parallelSend()calls for each chunkprocess_chunk- Processes chunks concurrently via LangGraph Send APIsynthesize_results- Merges chunk results into unified outputRelated Issue
Fixes #990
Type of Change
How Has This Been Tested?
uv run pytest)Test Details:
Design Alignment
Which design principles does this PR support? (See DESIGN_PRINCIPLES.md)
Explanation:
Checklist
Code Quality
Testing
make rufforruff check . --fixDocumentation
Screenshots (if applicable)
N/A - Backend changes only
Additional Context
Files Modified:
open_notebook/graphs/transformation.py- Full restructure to use Send APItests/test_graphs.py- Updated imports for new function namesNew Components:
ChunkResult/ChunkState- TypedDicts for parallel processingtry_full_content()- Optimistic processing with error-based fallbackfan_out_chunks()- Conditional edge creating Send objectsprocess_chunk()- Individual chunk processorsynthesize_results()- Result aggregator usingAnnotated[list, operator.add]Pre-Submission Verification
Before submitting, please verify: